Skip to content

feat(db): persist users + resource selection — Part of #586 #980

Open
skypank-coder wants to merge 6 commits into
OWASP:mainfrom
skypank-coder:feat/586-users
Open

feat(db): persist users + resource selection — Part of #586 #980
skypank-coder wants to merge 6 commits into
OWASP:mainfrom
skypank-coder:feat/586-users

Conversation

@skypank-coder

Copy link
Copy Markdown
Contributor

What & why

First increment of #586 ("Add support for users"): login-first user
persistence. OIDC login existed but nothing was persisted — no User table,
nowhere to hang per-user data. This adds that foundation so per-user resource
filtering (PR2–PR4) can be built on it. Aligns with auth RFC #876 (TODO 1/2).

Changes

  • users table anchored on the immutable OIDC sub (google_sub); email
    and display_name refreshed on each login.
  • user_resource_selection table: one row per selected standard,
    UNIQUE(user_id, standard_name), FK → users.id ON DELETE CASCADE.
  • Login callback upserts the User and sets session['user_id'] — gated on
    CRE_ENABLE_LOGIN, wrapped so it never blocks login. Existing session keys
    and the anonymous 401 are unchanged (intentionally narrower than RFC RFC: User Authentication and Online MyOpenCRE Mapping #876's
    login_required rewrite).
  • Node_collection methods: upsert_user, get_user_by_sub,
    get_user_resource_selection, set_user_resource_selection. These back the
    /rest/v1/user resource endpoints coming in PR2.
  • Migrations: a dedicated merge revision collapses the two pre-existing
    open heads (ab12cd34ef56, 9b1c2d3e4f50), then a revision creates the two
    tables — keeping the head single-parent so flask db downgrade is
    unambiguous.

Testing

  • 12 new tests: idempotent upsert (no dup row), selection round-trip / replace /
    dedup / per-user isolation, unique constraint, cascade delete, callback upsert,
    and the flag-off no-op path.
  • Verified on real Postgres (prod parity), not just SQLite: single head,
    ON DELETE CASCADE enforced at the DB level (rows 1 → 0), both unique
    constraints created cleanly, migration upgrade → downgrade → upgrade
    deterministic, alembic revision guardrail green. 12/12 pass on Postgres.
  • black clean; no new mypy errors vs. baseline.

Scope boundaries

No new endpoints, no server-side filtering, no frontend, no login_required
rewrite — those are the follow-up PRs in #586.

Unrelated pre-existing issue (out of scope, flagging for visibility)

flask db upgrade from a blank DB fails on Postgres before reaching my
revisions: migrations 3c65127871a6 / 7bf4eac76958 reuse the constraint name
uq_pair (authored via SQLite batch_alter_table), which collides on a
from-base replay. Postgres rolls the whole thing back cleanly (transactional
DDL). This predates this branch and doesn't affect the prod path (apply-onto-
existing-schema), so it's out of scope here — but worth a separate fix, since
anyone bootstrapping a fresh Postgres DB will hit it.

skypank-coder and others added 6 commits July 9, 2026 20:18
…WASP#586)

Persist accounts on OIDC login and store a per-user resource (standard) selection. Additive to the existing session auth: the callback upserts the User row and sets session['user_id'] only when CRE_ENABLE_LOGIN is on; the existing session keys and 401 behaviour are unchanged.

- User(google_sub UNIQUE, email, display_name, created_at, last_seen_at)
- UserResourceSelection(user_id FK CASCADE, standard_name, UNIQUE(user_id, name))
- Node_collection: upsert_user / get_user_by_sub / get_user_resource_selection / set_user_resource_selection
- Alembic: dedicated merge of the two open heads + create tables (up/down verified on Postgres; guardrail green)
…int (OWASP#586)

Address PR review:
- upsert_user now mirrors add_node: catch IntegrityError, rollback, re-query
  by google_sub, update profile — safe under concurrent logins.
- Drop column-level unique=True on User.google_sub; keep only the named
  constraint so create_all matches the Alembic migration (no schema drift).
- Login callback skips persistence when the OIDC 'sub' claim is missing.
- Callback test asserts session['user_id'] equals the persisted User.id.
…OWASP#586)

Address PR review:
- Log only the exception class on login-persistence failure; the raw message
  can carry SQL parameters (email, OIDC sub).
- Add callback test: login enabled but token missing 'sub' creates no user
  row and leaves session['user_id'] unset.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Summary by CodeRabbit

  • New Features

    • Added persistent user accounts through OIDC login.
    • User profiles are updated on subsequent logins without creating duplicates.
    • Added per-user resource selection preferences, including replacement and deduplication.
    • Login sessions now retain the authenticated user’s identity.
  • Bug Fixes

    • Login continues safely when identity information is incomplete or persistence fails.
  • Tests

    • Added coverage for user persistence, session handling, resource selections, uniqueness, isolation, and cleanup.

Walkthrough

Changes

User persistence

Layer / File(s) Summary
User schema and migration lineage
migrations/versions/*users*.py, migrations/versions/*merge_heads*.py
Adds users and user_resource_selection tables with uniqueness, foreign-key cascade, and merged migration lineage.
User persistence APIs
application/database/db.py, application/tests/user_model_test.py
Adds SQLAlchemy models and APIs for user upserts, lookups, and deduplicated resource-selection replacement, with persistence and cascade tests.
OIDC callback integration
application/web/web_main.py, application/tests/web_main_test.py
Persists verified callback users when enabled, stores session["user_id"], and tests disabled-login and missing-sub behavior.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

  • OWASP/OpenCRE#876: Covers the related RFC-driven OIDC identity and per-user selection persistence design.

Suggested reviewers: robvanderveer, northdpole, paoga87, pa04rth, shreeshtripurwarcomp23-coder

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: persisting users and per-user resource selection.
Description check ✅ Passed The description accurately covers the user persistence, resource selection, migrations, tests, and scope boundaries in this PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
application/database/db.py (2)

1038-1089: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist the repeated datetime/timezone import to module scope.

from datetime import datetime, timezone is imported locally in both upsert_user (Line 1046) and set_user_resource_selection (Line 1105). Move it to the top-level imports once.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@application/database/db.py` around lines 1038 - 1089, Move the repeated “from
datetime import datetime, timezone” import from the methods upsert_user and
set_user_resource_selection to the module-level imports, leaving both methods to
use the shared top-level import without local import statements.

1101-1125: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

set_user_resource_selection has no guard against concurrent duplicate inserts.

Unlike upsert_user above (which recovers from IntegrityError on google_sub races), this delete-then-insert sequence has no equivalent safeguard. Two concurrent calls for the same user_id (e.g. a double-submitted "save selection" request) could both pass the delete step and then collide on uq_user_resource_selection for a shared standard_name, raising an unhandled IntegrityError. No endpoint calls this yet, but it's worth hardening before wiring it up.

♻️ Suggested guard mirroring upsert_user's recovery pattern
         self.session.query(UserResourceSelection).filter(
             UserResourceSelection.user_id == user_id
         ).delete()
         for name in deduped:
             self.session.add(
                 UserResourceSelection(
                     id=generate_uuid(),
                     user_id=user_id,
                     standard_name=name,
                     created_at=now,
                 )
             )
-        self.session.commit()
+        try:
+            self.session.commit()
+        except IntegrityError:
+            # A concurrent call raced on the same (user_id, standard_name) pair;
+            # the other writer's result wins, just return the current state.
+            self.session.rollback()
         return self.get_user_resource_selection(user_id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@application/database/db.py` around lines 1101 - 1125, Harden
set_user_resource_selection against concurrent IntegrityError races during its
delete-and-insert sequence. Mirror the existing recovery pattern used by
upsert_user: catch the uniqueness violation, roll back the failed transaction,
and return the persisted selection for user_id without leaving the exception
unhandled; preserve the current deduplication and replacement behavior for
non-conflicting calls.
application/web/web_main.py (1)

1164-1185: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Broad exception catch is intentional but worth narrowing eventually.

Ruff flags the blind except Exception here (BLE001). The rationale in the comment (avoid leaking SQL parameters) is sound for upsert_user failures specifically, but the broad catch also silently swallows unrelated bugs (e.g., a malformed id_info dict) that would otherwise be visible. Consider catching SQLAlchemyError specifically if you want DB failures to degrade gracefully while other exceptions still surface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@application/web/web_main.py` around lines 1164 - 1185, In the user
persistence block around upsert_user, replace the broad Exception handler with
SQLAlchemyError and import that exception from the project’s SQLAlchemy
dependency. Preserve the existing sanitized class-name logging and graceful
redirect for database failures, while allowing unrelated errors such as
malformed id_info data to propagate.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@application/database/db.py`:
- Around line 1038-1089: Move the repeated “from datetime import datetime,
timezone” import from the methods upsert_user and set_user_resource_selection to
the module-level imports, leaving both methods to use the shared top-level
import without local import statements.
- Around line 1101-1125: Harden set_user_resource_selection against concurrent
IntegrityError races during its delete-and-insert sequence. Mirror the existing
recovery pattern used by upsert_user: catch the uniqueness violation, roll back
the failed transaction, and return the persisted selection for user_id without
leaving the exception unhandled; preserve the current deduplication and
replacement behavior for non-conflicting calls.

In `@application/web/web_main.py`:
- Around line 1164-1185: In the user persistence block around upsert_user,
replace the broad Exception handler with SQLAlchemyError and import that
exception from the project’s SQLAlchemy dependency. Preserve the existing
sanitized class-name logging and graceful redirect for database failures, while
allowing unrelated errors such as malformed id_info data to propagate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 4fb83f8c-bd64-4f81-84cb-24a695e2c0d6

📥 Commits

Reviewing files that changed from the base of the PR and between 6b6a5ac and 1bb2424.

📒 Files selected for processing (6)
  • application/database/db.py
  • application/tests/user_model_test.py
  • application/tests/web_main_test.py
  • application/web/web_main.py
  • migrations/versions/a1b2c3d4e5f6_add_users_and_resource_selection.py
  • migrations/versions/f0e1d2c3b4a5_merge_heads_before_users.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant